Auto Increment 식별자의 한계와 대안
Auto Increment 식별자의 한계와 대안
단일 쓰기 DB에서는 여전히 좋은 기본값이다. 분산 생성, 병합, 오프라인 생성이 필요할 때 UUID나 시간 정렬 식별자를 검토한다.
목차
- #문제가 되는 상황
- #Auto Increment가 여전히 좋은 이유
- #번호에 빈칸이 생기는 것은 정상이다
- #순차 ID와 보안은 별개의 문제다
- #여러 writer와 데이터 병합의 한계
- #대안별 생성 방식 비교
- #Snowflake 계열 ID의 구성과 비용
- #내부 ID와 public ID를 함께 쓰기
- #분산 ID가 필요하지 않은 경우
- #migration할 때 주의할 점
- #실전 점검 목록
- #결론
- #관련 노트
문제가 되는 상황
UUID가 분산 시스템에 잘 맞는다는 이유로 모든 테이블의 BIGINT AUTO_INCREMENT를 바꿔야 할까. 단일 쓰기 데이터베이스에서 행을 생성하고 내부 join에 사용하는 경우 Auto Increment는 여전히 단순하고 공간 효율적인 선택이다.
문제는 키 생성 주체가 여러 DB로 나뉘거나, offline client가 서버에 보내기 전에 ID를 알아야 하거나, 서로 다른 database의 데이터를 병합할 때 생긴다. 외부 URL에 순차 숫자가 노출되어 데이터 규모와 생성 순서가 드러나는 것도 제품 요구에 따라 고려할 수 있다. 대안은 이런 실제 요구가 있을 때 선택해야 한다.
주문·레코드 테이블과 ID bit 구성은 비교를 위한 가상 예제다. 실제 서비스의 트래픽 규모나 식별자 규칙을 사용하지 않았다.
Auto Increment가 여전히 좋은 이유
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL,
PRIMARY KEY (id)
);
장점은 다음과 같다.
- 생성과 유일성 보장을 DB에 맡길 수 있다.
- 8바이트 정수는 index와 외래 키가 작다.
- 비교와 정렬이 단순하다.
- 대체로 증가 방향으로 삽입되어 B+Tree locality가 좋다.
- 운영자가 로그와 SQL에서 읽고 다루기 쉽다.
ORM도 생성 후 ID 반환을 잘 지원한다. 한 database writer가 identity를 소유하고 ID를 외부에서 미리 만들 필요가 없다면 복잡한 분산 ID 서비스보다 신뢰할 부분이 적다.
Auto Increment 값이 업무 순번, 청구서 번호, 법적 문서 번호를 자동으로 보장하는 것은 아니다. 그런 번호에는 별도의 발급 규칙과 transaction·감사 정책이 필요하다.
번호에 빈칸이 생기는 것은 정상이다
Sequence 값은 transaction rollback, insert 실패, 미사용 예약 값 때문에 건너뛸 수 있다.
START TRANSACTION;
INSERT INTO orders (customer_id, created_at)
VALUES (42, CURRENT_TIMESTAMP(3)); -- id 101 할당
ROLLBACK;
INSERT INTO orders (customer_id, created_at)
VALUES (42, CURRENT_TIMESTAMP(3)); -- 다음 값 102일 수 있음
101이 없다는 사실은 주문이 삭제되었다는 증거도, DB 오류도 아니다. ID를 연속된 업무 번호로 해석하면 “누락 번호 복구” 같은 위험한 로직을 만들게 된다.
// 잘못된 전제: 마지막 ID + 1을 다음 ID라고 가정한다.
const expectedNextOrderId = lastOrder.id + 1;
페이지 처리도 id + 1로 하지 않고 실제 존재하는 다음 행을 조건과 정렬로 조회한다.
SELECT id, customer_id, created_at
FROM orders
WHERE id > :last_seen_id
ORDER BY id ASC
LIMIT 100;
순차 ID와 보안은 별개의 문제다
/orders/1001 다음에 /orders/1002가 있을 가능성을 추측할 수 있다. 이는 데이터 규모와 생성 속도 같은 정보를 일부 노출할 수 있지만, 가장 큰 문제는 다른 사용자의 주문을 권한 검사 없이 반환하는 IDOR 취약점이다.
const order = await orderRepository.findById(request.params.orderId);
if (!order || order.customerId !== request.user.customerId) {
throw new NotFoundError();
}
UUID로 바꾸어도 유출된 URL이나 로그를 가진 사용자가 다른 사람의 리소스를 읽을 수 있다면 취약하다. 예측 불가능한 public ID는 enumeration을 어렵게 하는 방어층일 뿐 authentication과 authorization을 대신하지 않는다.
외부에서 총 가입자 수를 추측하는 것이 비즈니스상 민감하면 내부 sequence를 직접 노출하지 않고 별도 public ID를 제공할 수 있다.
여러 writer와 데이터 병합의 한계
두 독립 DB가 각각 id=101을 생성하면 나중에 데이터를 합칠 때 충돌한다.
flowchart LR
A[Region A DB
id 101] --> M[Merge]
B[Region B DB
id 101] --> M
M --> X[Primary Key Conflict]offset과 increment 범위를 나누는 방법도 있지만 writer 추가와 재구성에 운영 비용이 든다.
Region A: 1, 3, 5, 7, ...
Region B: 2, 4, 6, 8, ...
다음 요구가 있다면 분산 생성 ID를 검토한다.
- 여러 region이 중앙 DB 연결 없이 동시에 행을 만든다.
- offline client가 sync 전에 영구 ID를 알아야 한다.
- event를 DB insert 전에 식별해야 한다.
- 독립 database의 데이터를 자주 병합한다.
- ID 생성 장애가 중앙 sequence 병목이 되면 안 된다.
다만 전역 ID만 해결한다고 multi-region write가 완성되는 것은 아니다. 충돌하는 업무 데이터, 복제 지연, ordering, clock skew를 별도로 해결해야 한다.
대안별 생성 방식 비교
| 방식 | 크기 | 중앙 조정 | 대략적 정렬 | 주요 비용 |
|---|---|---|---|---|
| Auto Increment / sequence | 8바이트 | DB 필요 | 생성 순서에 가까움 | 여러 writer 조정 |
| UUID v4 | 16바이트 | 불필요 | X | 무작위 index 삽입 |
| 시간 정렬 UUID | 16바이트 | 불필요 | O | 시간 정보·구현 통일 |
| Snowflake 계열 | 보통 64비트 | worker ID 조정 | O | clock·worker 관리 |
| 중앙 ID service | 설계에 따라 | service 의존 | 가능 | 가용성·latency |
| DB별 range 할당 | 정수 | range allocator | O | range 관리·낭비 |
“충돌 확률이 매우 낮다”와 DB unique 보장은 다른 층이다. UUID를 사용해도 primary key 또는 unique constraint를 둔다. 문자열 36자로 저장할지 16바이트 binary로 저장할지도 index 크기와 운영 편의의 trade-off다.
UUID v4와 시간 정렬 UUID의 저장 방식은 UUID v4와 시간 정렬 UUID 비교에서 자세히 다룬다.
Snowflake 계열 ID의 구성과 비용
64비트 안에 timestamp, worker ID, 같은 시간 단위의 sequence를 배치하는 방식이 흔하다.
| timestamp | worker id | sequence |
장점은 작은 정수로 여러 worker가 대략 시간순 ID를 만들 수 있다는 것이다. 대신 다음 운영 규칙이 필요하다.
- worker ID가 동시에 중복 할당되지 않아야 한다.
- 시스템 시간이 뒤로 이동할 때 생성 정책이 있어야 한다.
- 한 시간 단위 안의 sequence를 초과하면 기다리거나 실패해야 한다.
- epoch와 bit 배치를 모든 서비스가 공유해야 한다.
- 숫자 type이 안전하지 않은 JavaScript client에서 문자열로 전달할 수 있다.
type PublicEvent = {
// JSON number로 보내면 큰 정수 정밀도를 잃을 수 있어 문자열로 표현한다.
id: string;
};
직접 generator를 만들기보다 검증된 구현을 사용하고, clock rollback과 worker collision을 chaos test한다. “중앙 DB를 제거했다” 대신 worker lease나 설정 배포라는 새 조정 지점이 생길 수 있다.
내부 ID와 public ID를 함께 쓰기
내부 join에는 Auto Increment를 유지하고 외부에 별도 ID를 제공하는 절충안이 있다.
CREATE TABLE records (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
public_id BINARY(16) NOT NULL,
payload JSON NOT NULL,
created_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_records_public_id (public_id)
);
flowchart LR
API[API public_id] --> L[Lookup unique index]
L --> PK[Internal BIGINT PK]
PK --> FK[작은 foreign keys와 joins]이 구조는 index 하나와 lookup을 추가한다. 모든 endpoint가 public ID만 쓰도록 repository 경계를 정하고, 내부 ID가 실수로 JSON과 이벤트에 노출되지 않게 presenter를 둔다.
public ID를 application에서 생성하면 DB insert 전 event correlation에도 사용할 수 있다. 두 ID를 운영해야 하는 복잡성이 이점보다 큰 작은 내부 서비스라면 하나의 key가 낫다.
분산 ID가 필요하지 않은 경우
다음 상황에서는 Auto Increment가 충분할 가능성이 높다.
- write primary가 하나이고 장애 조치는 DB가 관리한다.
- ID는 DB insert 이후에 알아도 된다.
- 데이터를 독립적으로 생성해 병합하지 않는다.
- 외부 노출이 없거나 public ID 분리가 필요하지 않다.
- 실제 병목은 ID 생성이 아니라 query·lock·I/O다.
UUID 도입은 key 크기 증가, 디버깅 불편, index locality, byte order와 library 호환을 함께 가져온다. “언젠가 분산할 수 있다”는 막연한 가능성보다 현재와 예상되는 writer 구조를 근거로 선택한다.
migration할 때 주의할 점
기존 BIGINT를 UUID primary key로 바로 교체하면 모든 외래 키와 보조 index를 다시 만들어야 할 수 있다. public ID만 필요한 경우에는 PK를 유지하고 unique UUID 컬럼을 추가하는 migration이 작다.
- nullable
public_id와 unique index 준비 방식을 검토한다. - 기존 행을 batch로 backfill한다.
- 새 insert에서 반드시 생성하도록 한다.
- 중복·NULL 검증 후
NOT NULL을 적용한다. - API read와 URL을 점진적으로 public ID로 전환한다.
- 구 숫자 URL의 종료 또는 redirect 정책을 정한다.
SELECT COUNT(*) AS missing_public_ids
FROM records
WHERE public_id IS NULL;
큰 테이블에서는 online DDL 지원, replication lag, backfill throttle과 rollback을 확인한다.
실전 점검 목록
- 실제로 여러 writer가 중앙 조정 없이 ID를 만들어야 하는가?
- ID gap을 업무 누락으로 잘못 해석하지 않는가?
- public ID와 권한 검사를 서로 다른 문제로 다루는가?
- 대안의 key 크기와 index 삽입 패턴을 측정했는가?
- 시간 기반 ID의 clock·worker 충돌 정책이 있는가?
- 내부·외부 ID 두 개의 운영 비용을 감수할 이유가 있는가?
- migration에서 모든 외래 키를 바꾸지 않는 작은 경로를 검토했는가?
단일 쓰기 DB에서는 여전히 좋은 기본값이다. 분산 생성, 병합, 오프라인 생성이 필요할 때 UUID나 시간 정렬 식별자를 검토한다.
결론
Auto Increment는 단일 쓰기 DB에서 작고 단순하며 index locality가 좋은 기본값이다. 번호 gap은 정상이고 순차 ID의 추측 가능성은 권한 검사와 별개의 문제다. 여러 writer·offline 생성·데이터 병합이 실제 요구일 때 UUID나 Snowflake 계열을 검토하고, 외부 노출만 문제라면 내부 BIGINT와 별도 public ID를 함께 쓰는 작은 변경부터 고려한다.